Time-to-offer on the employer dashboard: stamp the duration on ats_application, seed offers for hired applications, and correct §04's wording - #68
Conversation
Closes #55. The fourth tile of ats_employer_hiring, and the column it needs. - ats_application.days_to_offer (number, min 0): whole days from applied_at to the FIRST offer's created_at. A dataset measure aggregates one column of one object, so a duration spanning ats_application and ats_offer has to be a column before the semantic layer can average it. - OfferTimeToOfferHook: afterInsert on ats_offer, runAs system, writes the column by id. It subscribes to no *Update event at all, so the #43 shape (claimSeedOwnership's predicate update smearing one row's value across the batch) has no surface here; the guard that fits an insert-only stamp is days_to_offer == null on the target row, which also makes a re-boot on an existing database and a re-issued offer both no-ops. - ats_application_metrics gains avg_days_to_offer (aggregate: avg). No measure-scoped filter: that compiles to a conditional aggregate the memory driver answers 501 to. The tile filters at the widget instead. - The dashboard comment block explaining why the tile was absent is replaced with what the tile is; the pipeline bar moves down one row. - en + zh-CN labels for the field, the measure and the widget (lint runs --i18n-strict). - docs/backlog/13-dashboards.md said "median days" under a heading that restates DESIGN.md §04. §04 itself has read 平均到 Offer 天数 since the bootstrap commit, so the card was the half that diverged; it now reads "average days". DESIGN.md is unchanged. Measured, memory and sqlite, after [Seeder] Seed loading complete: the seed loader DOES fire afterInsert, so all 23 offer-bearing applications (9 hired among them) are stamped at boot with no seed change. Quillstone's tile reads 30 over 1 hired application, Harborline's 58 over 2, each matching an independent recomputation from applied_at and the first offer's created_at that never reads the column. A no-op PATCH leaves all nine values (five distinct) unchanged, and a second boot on the same sqlite database reproduces them exactly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
The field's own description says "not editable and not recomputed". The second half was enforced by the hook; the first half was not — `readonly` defaulted to false, and an ordinary employer administrator could PATCH `days_to_offer` on their own hired application and move the Average Days to Offer tile with it. Measured before the fix: 30 -> 1, HTTP 200, value persisted. `readonly: true` closes it, and costs nothing: the engine's strip reads hook-write provenance, so the `afterInsert` stamp still writes the column while a caller's value is dropped. Measured after the fix, on a seeded memory boot: all nine hired applications stamped [30,30,36,49,58,58,58,66,66] exactly as before, the same PATCH now leaves the row at 30, and the tile still reads 30 over 1. A declared-but-unenforced claim is the defect class this repository keeps closing (#18, #13, #32, #56, and #63's whole premise); it should not ship a new one in a field description. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
A seed cannot set `created_at`, so every seeded offer is created at boot and `days_to_offer` equals the application's AGE — verified against `appliedDaysAgo` in the seed skeleton, an exact match on all nine hired rows and on all 23 stamped ones. Quillstone's 30 and Harborline's 58 read as "took 30 / 58 days to decide" and actually mean "filed 30 / 58 days ago". The metric is right and the tile computes it correctly; the demo has no real elapsed time in it to compute over (#65). Recording that where someone reading the tile will find it, rather than letting the number be quoted as product history. Comment only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
Review — verified independently; two follow-up commits pushedRe-measured in a clean worktree at The stamped column, checked against a source the implementation never readsThe PR's "independent recomputation" re-derives Nine rows, five distinct values, none null; and exactly 14 non-hired applications carry the column, all of them Finding 1 — the field says "not editable" and was editable. Fixed in
|
with readonly: true |
result |
|---|---|
| all nine hired stamped at boot | ✅ [30,30,36,49,58,58,58,66,66] — byte-identical to before |
the same employer-admin PATCH days_to_offer=1 |
HTTP 200, value stays 30 (stripped) |
| tile after the attempted tamper | still 30 over 1 |
One line, no cost, claim now true. pnpm validate && pnpm lint && pnpm typecheck all exit 0.
Finding 2 — the demo number is not the history it reads as. Documented in 81bf271.
created_at is the platform's own stamp, so every seeded offer is created at boot — which means days_to_offer is arithmetically identical to the application's age for all 23 stamped rows. That is not a guess: it is why my seed-skeleton check above matched at all. appliedDaysAgo is the column, for every row.
So Quillstone's 30 and Harborline's 58 read as "this employer took 30 / 58 days to decide" and actually mean "filed 30 / 58 days ago".
Nothing in this PR can fix that — a seed cannot set created_at, and the metric is faithful to DESIGN §04 as written. It is the same root as the #65 you filed: the demo has no real elapsed time in it. But the numbers on a dashboard are exactly the kind of thing that gets quoted as a product fact later (this repo already quotes 88 / 46 / 28 / 14 / 9 in its README), so I put the caveat in the dashboard comment where someone reading the tile will meet it. Comment only; gates re-run green.
The falsified premise — confirmed, and it was my error, not yours
You were right and I checked it myself: DESIGN.md §04 line 290 reads 「平均到 Offer 天数」, and git log -S "中位" -- DESIGN.md is empty across all 29 commits of a non-shallow clone. I put that bullet into the card when I rewrote it an hour ago, carrying the original wording forward without opening the file it named. Moving the word in docs/backlog/13-dashboards.md — a restatement under a ## Spec — DESIGN.md §04 heading that had drifted from the authority it cites — is the correct read, and leaving DESIGN.md untouched is the correct outcome. Declining to take a dispatch's premise on trust is the behaviour I want; thank you for measuring it instead of complying.
The rest, checked and clean
- The hook subscribes to
afterInsertonly, confirmed in the artifact (ats_offercarriesats_offer_stampon insert/update and this one on insert alone), soclaimSeedOwnership's predicate update genuinely cannot reach it — the Stamp hooks write one arbitrary employer's org onto every row:ctx.api.object(...).findOne({ where: { id } })inside the hook sandbox returns an unrelated row (every seededats_jobreadsemployer_org = org_ats_orbit) #43 surface is absent by construction, not merely guarded. - The
days_to_offer == nullguard makes first-offer-wins and re-boot-safety the same line. - No dataset measure-scoped filter; the widget filter is where the
stagepredicate lives, which is what keeps the memory driver off its501path. - Layout: the new KPI takes
y: 2and the pipeline bar moves toy: 4— no overlap with the three existing tiles aty: 0. - Both locales carry the field, measure and widget strings;
--i18n-strictgreen.
Merging once CI is green on 81bf271.
Generated by Claude Code
Closes #63. scripts/check-i18n-source.mjs compares every key in src/translations/en.ts against the label its metadata declares in dist/objectstack.json and fails on three kinds of drift: a value that no longer matches its source label, a bundle key whose metadata is gone, and a source label the bundle never restates. pnpm lint chains it via pnpm check:i18n-source, so CI and a laptop run the same gate without a fourth command. Coverage is 507 of 507 keys with an empty remainder: 482 resolve through `os i18n extract --json` and 25 through a local resolver for text nested inside a view document; the platform's 769 metadataForms.* keys are excluded and named. An unrecognised key shape is reported as an orphan and fails, so the coverage number cannot rot silently. The script self-tests before every run: 7 assertions proving the comparator still reports drift, orphans and gaps. Verified in review by crippling compare() — the real check then passes a genuinely drifted tree with exit 0 while the self-test catches it with exit 1. Also verified in review: the same tree that `objectstack lint --i18n-strict` passes with exit 0 (a deleted en key) fails the new chain, so the gate adds coverage rather than restating one; drift is caught through both collectors, including a view-nested label; and the chain is green at 512/512 against main as it now stands with #68's five new keys. README, CONTRIBUTING and AGENTS now describe the gate list truthfully — CONTRIBUTING's claim that lint fails on a key missing from either locale file was already false, since --i18n-strict counts the source locale as 100% translated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
Fixes #55.
The fourth tile of
ats_employer_hiring— Average Days to Offer — and the stored column it needs.The seed-hook question, answered by measurement first
The card made everything downstream conditional on one fact: does the seed loader fire
afterInserthooks? It does. Measured on a demo-seeded boot, after[Seeder] Seed loading complete {"inserted":818,...,"errored":0}:All 23 offer-bearing applications are stamped at boot, the 9 hired among them. So the pre-authorised
src/data/path was NOT taken —src/data/is untouched, the funnel still reads 88 / 46 / 28 / 14 / 9 and the seed still loads 818 rows.The number, and the independent computation it was checked against
Both employer personas, on both drivers, tile vs. a recomputation that never reads the new column — it re-derives the duration from each application's
applied_atand its first offer'screated_at, over the rows that persona can read:avg_days_to_offerapplication_countadmin@quillstone.example[30]→ 30.0admin@harborline.example[58, 58]→ 58.0Identical on memory and sqlite. Hand-checkable against the seed: Quillstone's one hired application was filed 30 days before seed time, Harborline's two were both filed 58 days before.
The compiled SQL, printed by the probe so the applied filter is visible:
stage = $1is the widget filter;employer_org IN ($2)is RLS, applied per caller — which is why the same dataset answers 30 to one employer and 58 to the other.The measurement trap, reproduced
Same query, same persona, filter moved to each of the other keys. Every one returns HTTP 200 and silently drops the predicate:
stagein the compiled SQL?selection.runtimeFilteravg_days_to_offer: 30, application_count: 1body.filteravg_days_to_offer: 30.333…, application_count: 27selection.filteravg_days_to_offer: 30.333…, application_count: 27body.whereavg_days_to_offer: 30.333…, application_count: 2730.333…is a plausible-looking number that is not the tile's. (It is the average over Quillstone's three stamped applications, hired or not —COUNT(*)counts all 27,AVGignores NULLs. That NULL-skipping is deliberate and is what keeps hired applications that never reached an offer out of the denominator rather than counted as zero.)The #43 regression test — every hired application, not just the first
Nine applications, five distinct values. The #43 defect would collapse them to one.
PATCH— three of them,{},{"rating": 4}(same value) and{"stage": "hired"}(same value), all HTTP 200:CHANGED: none — all 9 unchanged, distinct values[30, 36, 49, 58, 66]before and after.[Seeder] Seed loading complete {"inserted":0,"updated":207,"skipped":611,"errored":0}— the upsert pass, andclaimSeedOwnershipbehind it): all nine values reproduce exactly, both tiles unchanged at 30 and 58.afterInsertonly.claimSeedOwnership'supdate(…, {where: {owner_id: null}, multi: true})dispatches nothing into it, so there is no payload to inspect and no per-row recompute a batch-scopedSETclause could smear. The guard that fits an insert-only stamp isdays_to_offer == nullon the target row — which is also the metric decision (time to first offer) and the re-boot guard.21, independently computed21from that offer's realcreated_at; inserting a second offer on an already-stamped application left it at23.Both drivers
--fresh, no persistence)What I could and could not see on sqlite. As an employer persona, everything: their applications, their offers, the tile, and the independent recomputation from their own offer rows. As a platform persona,
ats_applicationreads all 200 rows (so the nine-rowdays_to_offersnapshot above is from a platform read on sqlite), butats_offerreads 0 — #39, known upstream, not this change. So the marketplace-wide recomputation from offers is not available to a platform persona on sqlite; that one was done on the memory driver, where the same persona reads all 23 offers.What changed
src/objects/application.object.tsdays_to_offer—Field.number({ min: 0 }). A duration the analytics layer can average has to be a column.src/hooks/stamp.hook.tsOfferTimeToOfferHook—afterInsertonats_offer,runAs: 'system', by-idupdate({ id, days_to_offer }). Self-contained, like every handler in that file, so it stays lowerable to a metadata-only body (os buildreports all 9 callables body-only; the lowered body's inferred capabilities are['api.read', 'api.write']).src/hooks/index.tssrc/datasets/application.dataset.tsavg_days_to_offermeasure,aggregate: 'avg'. No measure-scopedfilter— that compiles to a conditional aggregate the memory driver answers501to.src/dashboards/employer-hiring.dashboard.tslayout.y2 → 4).src/translations/{en,zh-CN}.tspnpm lintruns--i18n-strict.docs/backlog/13-dashboards.mdsrc/security/,src/data/,src/views/,src/apps/, the other two dashboards,src/objects/employer.object.tsandsrc/objects/job.object.tsare all untouched.The card asks for §04's wording to move from median to average. §04 does not say median, and never has. Line 290 reads:
Evidence, over the repository's full history (unshallowed first — 29 commits, root
03fa775):So
DESIGN.mdis not modified by this PR. The text that actually said "median" wasdocs/backlog/13-dashboards.md, under a heading that reads## Spec — DESIGN.md §04— i.e. a restatement of §04 that had diverged from it. That is the sentence that moved, and it is the whole change to that file:The only surviving "median"s in
src/are the two that explain the choice ("avg, not median: the aggregate set is …").docs/evidence/issue-8/keeps its wording — it is a record of what was measured then, not a live spec.Gates
Exit codes captured before any pipe, on the committed tree:
The three
approval-approvers-may-resolve-emptysuggestions are pre-existing and concernemployer_verificationandjob_publish_review; this diff touches no flow.Out-of-scope findings, filed not fixed
03-hiring-home-overview.pngpredates the Average Days to Offer tile #66 —docs/screenshots/03-hiring-home-overview.pngpredates this tile. Its caption's four numbers are all still correct (no stage counts move), but the image no longer shows the dashboard the app ships. Regenerating a release screenshot is its own documented procedure, and updating the caption alone would make it describe something the image does not show.last_activity_atis boot time: the stamp hook overwrites the seeded value unconditionally #65 — all 200 demo applications carrylast_activity_atinside a 0.7-second window of boot:ApplicationStampHookassigns it unconditionally onbeforeInsert, so the seed'sactivityDaysAgonever reaches the database. Pre-existing onmain; found because it is what makes this PR's extra write to the application free of cost.🤖 Generated with Claude Code
https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
Generated by Claude Code